Add dims support to sort, sort!, sortperm and sortperm! - #117
Conversation
8410490 to
b7b31ad
Compare
|
@christiangnrd Please take a look. :) |
NVIDIA RTX 5080 (CUDA 13.3.0)N-D
|
|
Ignoring the benchmarks that this PR doesn't touch, these numbers don't look great |
Sort or permute each 1D slice along an integer `dims` independently, matching Base. The default `dims=:` keeps the existing flat behaviour. There is no batched sort kernel, so each element is tagged with its slice and the whole array is sorted once by (slice, value), then scattered back into place. sortperm carries the original index as the payload and uses it to break ties, keeping the permutation stable. This reuses the backend's tuned sort and runs unchanged on CPU and every GPU backend.
b7b31ad to
aaed884
Compare
Replace the tag-and-sort implementation of `dims` (tuple keys, a global sort of the whole array and a scatter back) with merge sort kernels that know about slices. A `SliceLayout` describes the 1D slices of an array along a dimension as `len` elements `stride` apart; each block maps its linear index to a slice and a tile within it, and accesses the slice through a `SliceView`. Slices up to twice the block size are sorted in local memory in one launch, larger ones get per-slice global merge passes. The whole-array sort is a one-slice `FlatLayout` whose accessor returns the array itself, so that path is unchanged. `merge_sort!`, `merge_sort_by_key!`, `merge_sortperm!` and `merge_sortperm_lowmem!` take `dims` directly; `sortperm` keeps linear indices as the values of a key/value sort, which is stable. On CPU each slice is sorted with `Base.sort!` on a view, slices spread over tasks, and `sortperm` sorts linear indices with a `Perm` ordering like Base. `RadixSort` with `dims` throws instead of silently switching algorithms, and the low-level sortperm entry points now default to `lt=isless`. Sorting 1024x1024 Float32 along a dimension on an RTX 5080 drops from 0.51 to 0.16 ms (CUDA.jl's bitonic sort: 0.31 ms), sortperm from 0.66 to 0.19 ms; the M1 and an Intel iGPU see 3-7x. The dims tests now also run on the CPU path and cover tile boundaries, global merge passes, ties, NaNs, strided views and empty slices.
|
I reworked the implementation on top of your commit, keeping the Tag-and-sort (the same scheme JuliaGPU/AMDGPU.jl#1033 uses as a stopgap) is what made the numbers disappointing: it allocates an array of The existing merge kernels now use a The flat layout still indexes the array directly. Whole-array timings remain close: 16M Other changes:
Performance:
oneAPI.jl forwards |
dims support to sort, sort!, sortperm and sortperm!
Sort the network ascending in `ord` with the reflected first step of every merge level, and skip comparators whose partner lies past the end: any length sorts without padding, so the sentinel buffer, the eltype whitelist and the Forward/Reverse restriction go away and NaNs order like Base. Every kernel takes the slice layout from JuliaGPU#117, so `dims` needs no separate path, slices of any length work, and slices shorter than a workgroup share a tile. Tunables live on `BitonicSort(; block_size, items_per_thread)` with backend defaults from `bitonic_defaults`.
|
I ran this on AMD, since it was not in the validation set above. RX 7900 XTX, ROCm 6.4.4, Julia 1.12. Your
8 of 9 cases faster for both, up to 2.5x for No regression on the flat path: 1M One observation: these land around 3x the RTX 5080 column, on a GPU of comparable memory bandwidth, so there may be ROCm-side headroom. Measurement details differ, so take it as a hint rather than a number. A tagged release would be much appreciated. AMDGPU.jl needs two lines on top of this: JuliaGPU/AMDGPU.jl#1076 |
|
Nice, thanks! I do want to first do a full pass over the new abstractions before releasing though, so that will still take a bit. Also, the plan is to relatively quickly wire this up to GPUArrays.jl, so maybe it's unnecessary to put this in AMDGPU.jl at all? |
I have this ready now JuliaGPU/AMDGPU.jl#1076 but yeah - if the GPUArray path would be better and land relatively soon then we could just wait on that. Else tagging a minor of AK would be helpful. |
|
Why not land the slow JuliaGPU/AMDGPU.jl#1033 as a stopgap and switch over to the fast implementation in a couple of weeks? |
Adds a
dimskeyword to the four sorting entry points, so each 1D slice of an array along one dimension is sorted (or permuted) independently, likeBase.sort!(A; dims):The default stays
dims=:, which sorts the whole array as one vector while preserving its shape. Integerdimsmust be in1:ndims(A); otherwise anArgumentErroris thrown. Thelt,by,revandorderkeywords apply within each slice.sortpermreturns stable linear indices intoA; with integerdims,sortperm!requiresixto have the same axes asA, or throws anArgumentError.On GPU,
block_sizecontrols the tile size andtempsupplies an array-sized merge buffer (values forsort!, indices forsortperm!); key buffers may still be allocated. The CPU slice path ignores both keywords.alg=AK.RadixSort()with integerdimsthrows anArgumentError. This closes #59 and provides the sorting support needed by JuliaGPU/AMDGPU.jl#1033 and JuliaGPU/GPUArrays.jl#608.How it works
The GPU merge sort kernels become slice-aware instead of gaining a separate batched sort. A small
SliceLayout(src/sort/slices.jl) describes the slices of an array along a dimension aslenelementsstrideapart; every block derives its slice from its linear block index and reads and writes it through aSliceViewusing a linear offset and stride. The merge logic is unchanged:2 * block_sizeelements (512 by default) need no global merges. Forsort!withby=identity, nontrivial slices take a single launch with no temporary array.nneedsceil(log2(n / tile))global passes whenn > tile, instead of using the total array lengthNto determine the pass count.The whole-array sort is a
FlatLayoutwith one slice, for which the slice accessor returns the array itself, retaining direct array indexing.sortpermreuses the key/value merge sort with the linear indices as values, exactly as the flat case does, and thealg=AK.MergeSort(lowmem=true)variant sorts indices while comparing values in the original array.sort!still hoists nonidentitybytransforms intokeys = by.(v);sortpermevaluatesbyduring comparisons.On CPU backends each slice is sorted with
Base.sort!on a view, with slices spread over tasks;sortpermfollows Base and sorts the linear indices of each slice by the values they point to.Performance
Float32, milliseconds, whole call including allocation, against the backend package's ownsort!(A; dims)/sortperm(A; dims)where it has one.NVIDIA RTX 5080 (compared with CUDA.jl):
For reference, CUB's
DeviceSegmentedSort(whattorch.sortuses) takes 0.14 ms forSortKeysand 0.13 ms forSortPairson the contiguous 1024 x 1024 case, and 0.04 / 0.14 ms for 256 slices of 4096 (kernel time only, excluding allocation). It pairs a radix sort with size-specialised kernels for small segments, is not stable, and only handles contiguous slices.Apple M1 (compared with Metal.jl):
Intel Xe iGPU (oneAPI.jl forwards
sort!to AK and itssortperm(A; dims)errors, so no vendor column):Not done here
RadixSortalongdimswould need a segmented radix sort.